jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
1<script>
2 import { onMount } from 'svelte';
3 import Poster from '../../lib/Poster.svelte';
4 import Chart from '../../lib/Chart.svelte';
5 import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
6 import RankingPosition from '../../lib/RankingPosition.svelte';
7
8 let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
9 let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
10 let username = $state(''), password = $state(''), slide = $state(0);
11 let touch = null, request = 0;
12 let authMode = $state('jellyfin');
13 const chapters = [
14 { id: 0, name: 'Your viewing recap' },
15 { id: 1, name: 'Your watch time', availability: 'exactWatchTime' },
16 { id: 2, name: 'Movies watched' },
17 { id: 3, name: 'TV shows watched' },
18 { id: 4, name: 'Unfinished movies', availability: 'completion' },
19 { id: 5, name: 'Your top genres' },
20 { id: 6, name: 'Viewing by hour' },
21 { id: 7, name: 'Viewing by weekday' },
22 { id: 8, name: 'Viewing by month' },
23 { id: 9, name: 'Your devices and apps' },
24 { id: 10, name: 'Your longest viewing session', availability: 'marathon' },
25 { id: 11, name: 'Your viewing profile', availability: 'personality' },
26 { id: 12, name: 'Your watch-time ranking', availability: 'ranking' },
27 { id: 13, name: 'Your year in summary' }
28 ];
29 // Navigation and chapter numbering follow only the available cards for this year.
30 let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
31 let card = $derived(cards[slide]?.id);
32 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
33 const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
34 const count = (n) => new Intl.NumberFormat().format(n);
35 const hours = (n) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n / 3600);
36 const duration = (n) => `${hours(n)} h`;
37 const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
38
39 async function api(path, options = {}) {
40 const res = await fetch(path, { credentials: 'same-origin', ...options });
41 const body = await res.json();
42 if (!res.ok) {
43 if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
44 throw new Error(body.error || 'The request could not be completed.');
45 }
46 return body;
47 }
48 async function loadReview() {
49 if (!year) return;
50 const current = ++request;
51 loading = true; error = ''; review = null; slide = 0;
52 try {
53 const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
54 if (current !== request) return;
55 if (data.schemaVersion !== 1) throw new Error('This review format is not supported. Ask the operator to update the app.');
56 review = data;
57 history.replaceState(null, '', `/review/${year}`);
58 } catch (e) { if (current === request) error = e.message; }
59 finally { if (current === request) loading = false; }
60 }
61 async function bootstrap() {
62 loading = true; error = '';
63 try {
64 const config = await api('/api/config'); authMode = config.authMode;
65 const session = await api('/api/session'); user = session.user; years = session.years;
66 const pathYear = location.pathname.split('/').filter(Boolean)[1];
67 year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
68 if (year) await loadReview();
69 } catch (e) { if (user || !e.message.includes('expired')) error = e.message; }
70 finally { loading = false; }
71 }
72 async function login(event) {
73 event.preventDefault(); signingIn = true; error = ''; notice = '';
74 try {
75 await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
76 password = ''; await bootstrap();
77 } catch (e) { error = e.message; }
78 finally { password = ''; signingIn = false; }
79 }
80 async function logout() {
81 notice = ''; error = '';
82 try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
83 catch (e) { notice = e.message; }
84 finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
85 }
86 function navigate(delta) { slide = Math.max(0, Math.min(cards.length - 1, slide + delta)); }
87 function keydown(event) {
88 if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
89 if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
90 if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
91 if (event.key === 'Home') { event.preventDefault(); slide = 0; }
92 if (event.key === 'End') { event.preventDefault(); slide = cards.length - 1; }
93 }
94 function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
95 function pointerup(event) {
96 if (!touch) return;
97 const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
98 if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
99 }
100 onMount(bootstrap);
101</script>
102
103<svelte:head><title>Jellyfin Year Review</title><meta name="description" content="Your private Jellyfin year in movies and television." /></svelte:head>
104<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
105
106<div class="app-shell" class:login-only={!user}>
107 {#if user}
108 <header class="topbar">
109 <a class="brand" href="/" aria-label="Jellyfin Year Review home"><span class="brand-icon">J</span><span>Jellyfin <span class="brand-light">Year Review</span><small>A YEAR WORTH REPLAYING</small></span></a>
110 {#if user}
111 <div class="account">
112 {#if years.length > 1}<label class="year-picker">Year<select aria-label="Review year" bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
113 <button class="quiet" onclick={logout}>Sign out</button>
114 </div>
115 {/if}
116 </header>
117 {/if}
118
119 <main>
120 {#if user}
121 {#if notice}<p class="message" role="status">{notice}</p>{/if}
122 {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>Try again</button></div>{/if}
123 {/if}
124 {#if !user}
125 <section class="login-layout" aria-label="Sign in">
126 <form class="login-panel" onsubmit={login}>
127 <h1>Your year,<br />replayed.</h1><p>Sign in with your existing Jellyfin account.</p>
128 {#if notice}<p class="message" role="status">{notice}</p>{/if}
129 {#if error}<div class="message error" role="alert">{error}</div>{/if}
130 <label for="username">Jellyfin username</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
131 <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
132 <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? 'Loading…' : signingIn ? 'Signing in…' : 'Roll the credits'} <span aria-hidden="true">↗</span></button>
133 <small class="login-note">{authMode === 'offline' ? 'Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.' : 'Your credentials are validated by your Jellyfin server. Your review stays private.'}</small>
134 </form>
135 </section>
136 {:else if loading}
137 <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>Setting the scene…</h1><p role="status">Loading your private review</p></section>
138 {:else if !years.length}
139 <section class="state-screen"><p class="eyebrow">NOT QUITE SHOWTIME</p><h1>Your story is on its way.</h1><p>No review has been generated for your account. Ask your server operator to generate a year.</p><button class="primary" onclick={bootstrap}>Check again</button></section>
140 {:else if review}
141 <div class="review-meta"><span>{review.year} / {user.name}</span></div>
142 <nav class="progress" aria-label="Review chapters">{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => slide = i}><span></span></button>{/each}</nav>
143 <section class="story tone-{slide % 4}" aria-roledescription="carousel" aria-label="Your yearly review" onpointerdown={pointerdown}>
144 <div class="story-orbit" aria-hidden="true"></div>
145 <div class="story-body" aria-live="polite" aria-atomic="true">
146 <p class="eyebrow">CHAPTER {String(slide + 1).padStart(2, '0')} / {cards[slide].name.toUpperCase()}</p>
147 {#if card === 0}
148 <span class="year-stamp">{review.year}</span><h1>Your viewing recap</h1><p class="story-lede">{user.name}, {review.records ? 'see the movies, TV shows, and viewing habits that made up your year.' : 'no viewing activity was recorded for this year.'}</p><button class="primary" onclick={() => navigate(1)}>View my recap <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
149 {:else if card === 1}
150 <h2>Your watch time</h2><div class="big-stat">{hours(review.seconds)}<span>hours watched{review.availability.exactWatchTime.available ? '' : ' · end-attributed estimate'}</span></div>
151 <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>Movies</span></div><div><b>{duration(review.tvSeconds)}</b><span>Television</span></div><div><b>{count(review.records)}</b><span>Activity records</span></div><div><b>{count(review.uniqueTitles)}</b><span>Unique movies & episodes</span></div></div>
152 {:else if card === 2 || card === 3}
153 {@const items = card === 2 ? review.movies : review.shows}
154 <h2>{card === 2 ? 'Movies watched' : 'TV shows watched'}</h2>
155 <p>Everything you watched in {review.year}, grouped by month.</p>
156 <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'Movies' : 'TV shows'} />
157 {:else if card === 4}
158 <h2>Unfinished movies</h2><p class="story-lede">Movies where you watched less than {review.rules.completionPercent}% of the runtime.</p>
159 {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{Math.round(item.completionPercent)}% of the movie watched</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? 'No unfinished movies found in supported records.' : 'Completion is unavailable without runtime and actual position ranges.'}</p>{/if}
160 {:else if card === 5}
161 <h2>Your top genres</h2><p>Genres ranked by watch time.</p>{#if review.genres.length}<Chart values={review.genres.slice(0, 7).map(v => v.seconds / 3600)} labels={review.genres.slice(0, 7).map(v => v.name)} metric="h" />{:else}<p class="empty-note">No genre history is available.</p>{/if}
162 {:else if card === 6}
163 <h2>Viewing by hour</h2><p>Your viewing activity across the hours of the day.</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} /></div>
164 {:else if card === 7}
165 <h2>Viewing by weekday</h2><p>Your viewing activity for each day of the week.</p><Chart values={review.weekdays} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} />
166 {:else if card === 8}
167 <h2>Viewing by month</h2><p>Your viewing activity for each month of {review.year}.</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} />
168 {:else if card === 9}
169 <h2>Your devices and apps</h2><p>Your most-used devices and apps, ranked by watch time.</p><div class="two-columns"><div><h3>Devices</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => v.name)} metric="h" /></div><div><h3>Apps & clients</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => v.name)} metric="h" /></div></div>
170 {:else if card === 10}
171 <h2>Your longest viewing session</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>hours watched in your longest session</span></div><p class="story-lede">{duration(review.marathon.elapsedSeconds)} elapsed · {review.marathon.records} records</p><p>{new Date(review.marathon.start).toLocaleString(undefined, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{review.availability.marathon.reason}</p>{/if}
172 {:else if card === 11}
173 <h2>Your viewing profile</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{review.personality.name}</h3><p class="story-lede">{review.personality.explanation}</p>{:else}<p class="empty-note">{review.availability.personality.reason}</p>{/if}
174 {:else if card === 12}
175 <h2>Your watch-time ranking</h2><p>Your total watch time compared with other eligible viewers.</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>of {review.ranking.cohort} eligible viewers</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>Viewers with less watch time</span></div><div><b>{duration(review.seconds)}</b><span>Your total hours watched · movies & TV shows</span></div></div>{:else}<p class="empty-note">{review.availability.ranking.reason}</p>{/if}
176 {:else}
177 <p class="year-stamp">SUMMARY / {review.year}</p><h2>Your year in summary</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>Your watch time</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{review.personality?.name || 'No viewing profile'}</b><span>Your viewing profile</span></div>{/if}</div><p class="story-lede">You have reached the end of your viewing recap.</p><button class="primary" onclick={() => slide = 0}>View recap again ↺</button>
178 {/if}
179 </div>
180 <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label="Previous chapter" onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label="Next chapter" onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
181 </section>
182 <details class="source-notes"><summary>Behind your review · source & availability</summary><p>{review.source}</p><p>{review.coverage}</p><p>Snapshot: {new Date(review.generatedAt).toISOString()} · Format v{review.schemaVersion}</p>{#each review.warnings as warning}<p>{warning}</p>{/each}</details>
183 {:else}<section class="state-screen"><h1>Let’s try that again.</h1><button class="primary" onclick={bootstrap}>Reload review</button></section>{/if}
184 </main>
185 {#if user}<footer><span>MADE FOR THE LOVE OF THE WATCH.</span></footer>{/if}
186</div>